Skip to content

fix: manage RCP source-match table so association/indirect poll ACKs carry Frame Pending - #126

Open
tostmann wants to merge 1 commit into
Nerivec:mainfrom
tostmann:fix/source-match-pending-table
Open

fix: manage RCP source-match table so association/indirect poll ACKs carry Frame Pending#126
tostmann wants to merge 1 commit into
Nerivec:mainfrom
tostmann:fix/source-match-pending-table

Conversation

@tostmann

@tostmann tostmann commented Jun 8, 2026

Copy link
Copy Markdown

Problem

The driver never wrote the RCP source-match (pending) table and relied on the RCP's default Frame Pending behaviour for the hardware auto-ACK it emits in response to a device's MAC data request poll. That default is not portable across RCP vendors/firmware.

On the ESP32 OpenThread RCP this prevents devices from joining. The stack reset the driver performs during start() leaves the radio in the enhanced auto-pending mode with an empty source-match table, so every poll ACK carries Frame Pending = 0. A joining device:

  1. sends its association request,
  2. polls (MAC DATA_RQ) for the association response,
  3. reads Frame Pending = 0 in the poll ACK as "no data pending",
  4. abandons the association before adopting the short address from the (indirectly delivered) association response,
  5. never acknowledges the transport key → never receives the network key → re-associates in a loop.

The same empty table also suppresses Frame Pending for sleepy end devices that have queued indirect data.

This matches the ESP32 reports in the firmware-stability discussion (devices repeatedly failing to join / "transport key never acknowledged" on ESP32-C6 ot_rcp).

Root cause

The host cannot set the Frame Pending bit per-poll: the RCP hardware auto-ACK is generated before the poll is handed to the host. The only portable mechanism is to pre-load the RCP source-match table with the extended addresses that currently have data pending, and let the radio set Frame Pending from a table lookup.

Fix

OTRCPDriver.updateSrcMatchTable() rebuilds the set of extended addresses with MAC-layer data pending — devices awaiting an association response (pendingAssociations) plus devices with a non-empty indirect-transmission queue (indirectTransmissions) — and writes it via MAC_SRC_MATCH_EXTENDED_ADDRESSES.

The MAC handler signals membership changes through a new onSrcMatchUpdate callback:

  • pending association added (processAssocReq) / removed (processDataReq),
  • indirect queue becomes non-empty (sendFrame) / empty (processDataReq).

MAC_SRC_MATCH_ENABLED is set once in formNetwork. Rebuilding the full set on each change keeps it correct for concurrent joins and mirrors the existing framePending = !!indirectTransmissions.get(x)?.length predicate the stack already uses on outgoing frames, so behaviour stays consistent (and now portable) across RCPs.

Why this does not regress other RCPs

Enabling source-match changes the poll-ACK Frame Pending semantics for all polls, so the table must reflect all pending data, not just associations — hence both pendingAssociations and non-empty indirectTransmissions feed it. On an RCP that previously defaulted to Frame Pending = 1, devices now correctly get 0 only when nothing is queued for them; when data is queued they still get 1.

Validation

Built (tsc), linted (biome), full test suite green (incl. updated fixtures + 2 new unit tests for updateSrcMatchTable).

Validated on hardware with an ESP32-C6 ot_rcp RCP:

  • A device that previously failed to associate (Tuya TS011F) now adopts its short address, receives the network key, and announces.
  • An IKEA TRETAKT smart plug joins from a clean pairing, completes its full interview, and is then controllable end-to-end through Zigbee2MQTT (on/off commands actuate the device and state reports come back).

Validated on an ESP32-C6 RCP only (no TI/Silabs RCP on hand to exercise the no-regression path; the reasoning above is unchanged for those, but is not hardware-confirmed here).

Note: the Frame Pending bit could not be captured on-air directly (the available 802.15.4 sniffer drops ACK frames), but the behavioural change is unambiguous — the same device on the same RCP transitions from an endless re-association loop to a completed join solely with this change.

Notes

There is also an upstream ESP-IDF defect behind the "enhanced mode after reset" behaviour (otPlatRadioEnableSrcMatch ignoring its aEnable argument); that will be reported separately. This change makes zigbee-on-host correct and portable regardless of that firmware fix.

…carry Frame Pending

The driver never touched the RCP source-match (pending) table and relied on the
RCP's default Frame Pending behaviour for the auto-ACK it emits to a device's
data request poll. That default is not portable across RCP vendors.

On the ESP32 OpenThread RCP this breaks joining: the stack reset the driver
performs during `start` puts the radio into the enhanced auto-pending mode with
an empty source-match table, so every poll ACK carries Frame Pending = 0. A
joining device reads that as "no data pending" after its association data
request, abandons the association before adopting the short address assigned in
the association response, and never receives the transport key. The same empty
table also suppresses Frame Pending for sleepy end devices with queued data.

Manage the table explicitly instead. `updateSrcMatchTable` rebuilds the set of
extended addresses with MAC-layer data pending -- devices awaiting an
association response (`pendingAssociations`) plus devices with a non-empty
indirect-transmission queue -- and writes it via
MAC_SRC_MATCH_EXTENDED_ADDRESSES. The MAC handler signals membership changes
through a new `onSrcMatchUpdate` callback when a pending association is
added/removed and when an indirect queue becomes non-empty/empty.
MAC_SRC_MATCH_ENABLED is set during `formNetwork`. The host cannot set Frame
Pending per-poll because the hardware auto-ACK precedes host handling, so
pre-loading the table is the only portable mechanism.
@Nerivec

Nerivec commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Will require extensive testing across chips. This might not be supported in some firmware, and the behavior, if already implement on stack side, could be a problem as well if it doesn't handover control properly (or at all).
Might have to gate the whole thing behind a feature detection.

This might also fix some issues we've seen with TI stack (cc: @chris-1243)

@tostmann

Copy link
Copy Markdown
Author

Update from our side: we're stepping back from evaluating zigbee-on-host as a path to a comprehensive coordinator, so we won't be driving this PR further. The full rationale with bench evidence is in discussion #94 — short version: the failure that's genuinely Espressif-RCP-specific is one class, but a second, host-side trust-center / TCLK leave–rejoin loop reproduced on an EFR32 RCP as well, so "comprehensively functional" wasn't reachable for strict Zigbee-3.0 joiners even on a third-party radio.

On this change specifically: it does what it claims on the bench — with the joiner's EUI64 in the RCP source-match table, the association / indirect-poll ACK carries Frame Pending = 1, and a fresh device joins and completes its interview. But your concerns are the right ones, and they're exactly the work we won't be carrying: it needs the cross-chip testing, and it should be gated behind feature detection, since firmwares that already manage frame-pending on the stack side could fight it.

So please treat this as available rather than actively maintained — feel free to take it forward (the TI-stack angle you mentioned sounds like the more interesting lead) or close it, whatever fits the project. Thanks for the look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants